Repository navigation
Quiet the machine status, and make a lost connection calm - #792
Merged
Merged
Conversation
…when it can't connect, and say a lost connection once at the top of the document A healthy Chopin now says nothing. The "Planner: <login> attached" line under the composer is gone, and so is the connection label inside it, so the composer is the same size in every state. What is left is told where a person already looks. The Planner's state is on the document header's machine button (and in the phone top bar): its dot becomes a thin petrol spinner while the machine is being connected to, a warning where it cannot be, and a muted grey while the Planner is away. The button's name and the top of its menu say it in words about the machine, such as "Connecting to Build box" or "Can’t connect to Build box" with the reason in full, what has been tried and a "How to fix" link, now documented in docs/chopin-cli.md. A machine that cannot be connected to also warns on the composer's existing attached top strip, which gains a warning tone and eases open and shut with the shared collapse motion; the other states, and a Planner that is not attached, are told there only while a message is addressed to Chopin, so a person with no machine button still hears of them when it matters. The status model is one pure module, so the button, its menu and the strip cannot disagree. A lost connection is one InlineNotice in the warning roles at the top of the document's content (the top of Chat where Chat is all that shows, and just under the floating top bar on a phone): "You’re offline. Your changes are kept and will sync when you reconnect." It waits out the existing 1.5 second grace period so a blip shows nothing, and Retry joins it only after four failed reconnect attempts. Wire now reports an outage and counts each failed attempt, automatic or a person's, reusing its existing jittered backoff; the timing and decisions are pure and unit tested. The header status, the Reconnect to Reload fallback and the composer's label and button are removed, and nothing confirms a state that clears. A screen reader hears the notice once as it appears and "Reconnected" once when it is gone. Placement and copy follow Primer's degraded-experience, loading and notification messaging patterns and Apple's progress-indicator guidance, in Chopin's own tokens and components; the research and the vendors' documented offline behaviour are cited in the pull request. The e2e traces the notice and the strip frame by frame, so they are shown to ease and not jump, and to settle at once under reduced motion. The design audit plates, DESIGN.md and the docs describe the new places. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 19ea4b06-26bd-4515-94f7-1201b0f8463c) Assistant-verification: bun test (apps/web, packages, scripts) passed: 2617 tests across 322 files, including the new unit tests for outage counting, timing, planner status and the strips Assistant-verification: bun test (full, as run here) failed: connector and routes tests need an unsigned git config (332 pass with GIT_CONFIG_GLOBAL=/dev/null) and the memory adapter flaked under load and passes alone Assistant-verification: bun run types passed: every package and the e2e project type-check Assistant-verification: bun run ci passed: dprint, oxlint, tokens, design contract, design record and Impeccable show no new findings Assistant-verification: bun run design:behavior passed: 22 keyboard, focus, reduced-motion and reflow checks at two widths Assistant-verification: bun run e2e (14 specs) passed: 137 passed on the built client, the offline notice, Retry, reduced motion, the machine button states and the composer strips in Chromium Assistant-verification: bun run e2e machines.e2e.ts passed: 2 tests that failed only on commit signing pass with GIT_CONFIG_GLOBAL=/dev/null Assistant-verification: screenshot review passed: desktop and phone shots of each state were opened and checked against DESIGN.md Assistant-verification: design:browser unavailable: Linux visual baselines were not regenerated User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion Co-authored-by: Alex Lavaee <lavaman131@github.com> Assistant-duration: 72m 28s
…and keep a lost connection one calm notice that never covers the page The status words are now plain. A machine that is starting, cannot be connected to, is offline, has stepped away or is missing is told as "Connecting to Build box", "Can't connect to Build box", "Build box is offline. Your work is kept.", "Build box stepped away. Your work is kept until 10:31 AM." and "No machine is connected.", with ASCII apostrophes and none of Chopin's own terms: no Planner, attach, held or Decisions, and no mention of Chopin. A unit test walks every state, glyph and place that says them and fails on any of those words. The refusals shown in Chat for an @chopin message or a comment are machine-first and plain too ("Connect a machine to get a reply", "Can't connect to @mona's machine: <reason>"), with their error codes unchanged, and docs/agent-connection.md says the same. One pure function now decides where a state is told, so the machine button, its menu and the composer's strip cannot disagree. A person with the button reads every state there and the composer warns only for a machine that cannot be connected to. A person without it (no machine, a title bar that does not show one, a phone whose top bar an open child has taken) reads every state on the composer's top edge, always, and for a machine that cannot be connected to the reason is drawn beneath it. The reason is kept exactly as the machine reported it, with no trimming and no stop added or taken away. One polite live region owned by the room announces each problem state once as it is entered and nothing as it clears; the strips carry no live role of their own, so nothing is said twice. A lost connection is shown even while the request that asks why it was lost never answers: the outage the wire reports, not its status, drives the notice, the status turns to reconnecting as soon as the socket closes, the probe gives up after four seconds, and the network coming back wakes a wire that is waiting on it. The notice's timing and announcement are decided above wherever it is drawn, and Workspace draws it once above Chat and the document, so switching between them during an outage neither restarts its grace period nor announces it again. On a phone it floats under the top bar and its height is added to every pane's top inset, so it never covers the first heading or the first message, and it sits above the panes so Retry can be pressed. The composer's unused slot under the field is removed. The design audit plates, the docs and DESIGN.md show the strip with and without the button. The e2e checks the notice against real Document and Chat content on a phone, in a compact window, through tab switches, with a hung probe, and that the lost-edits alert is the one left after a rebuild. Residuals, honestly: the offline copy says changes are kept while edits made across a server rebuild are dropped and said to be in a small header alert, and transcript lines, run cards and Build tooltips written before this change still say Planner and Decisions. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 19ea4b06-26bd-4515-94f7-1201b0f8463c) Assistant-verification: bun test (apps/web, packages, scripts) passed: 2634 tests, including the new tests for placement, once-only announcements, verbatim reasons, plain words, the hung-probe wire and the single page notice Assistant-verification: bun test (full, as run here) failed: 101 connector and routes tests need an unsigned git config (332 pass with GIT_CONFIG_GLOBAL=/dev/null), and no other test failed Assistant-verification: bun run types passed: every package and the e2e project type-check Assistant-verification: bun run ci passed: dprint, oxlint (no new warning), tokens, design contract, design record and Impeccable show no new findings Assistant-verification: bun run design:behavior passed: 22 keyboard, focus, reduced-motion and reflow checks at two widths Assistant-verification: bun run e2e (15 specs) passed: 184 passed on the built client, including the hung probe, the phone notice over real content, tab switching, the strips with and without the button and the live region Assistant-verification: e2e phone overlap check passed: it failed with the notice's inset removed and passes with it Assistant-verification: screenshot review passed: desktop and phone shots of each state, with and without the machine button, and of Document and Chat during an outage were opened and checked against DESIGN.md Assistant-verification: design:browser unavailable: Linux visual baselines were not regenerated User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion Co-authored-by: Alex Lavaee <lavaman131@github.com> Assistant-duration: 1h 0m 11s converged, estimated 3h
…hat Build needs in machine words, and make the plain-words check catch detach Three gaps in the previous commit. When a document's machine said "No machine is connected." its button still drew the machine's own connected dot, so a green dot sat beside those words. The button now draws no dot at all for that state: the bare icon in tertiary ink, as for a person who has no machine. The status-graphics rules decide it: success is for a connected machine and the muted dot already means a machine that is there but unreachable, which a document with nothing connected is not, so absence is drawn as absence. Every state that is not healthy now draws the Planner's own dot or none, and that dot no longer carries the machine's `data-state` at all, so the green cannot come back through the cascade. The design audit plate takes its dots from the same `plannerGlyph` as the button, and its labels speak of the machine. A real app test with a connected machine that cannot reply checks the button on desktop and phone. Build plan's tooltip and the refusal after pressing it still said "Reconnect the Planner to build" and "Connect a Planner to this document to build". They now say "Reconnect the machine to build" and "Connect a machine to this document to build", the same machine-first words as "Connect a machine to get a reply". It is the machine, not "your machine", because the document may run on a machine a teammate shares. The connect copy is one constant shared by the tooltip, the refusal and the key Build plan uses to offer how to connect a machine, so they cannot drift apart. The plain-words test now walks every reason Build plan waits and every refusal Build shows, and Build plan's tooltip with its connect key. The plain-words pattern missed "detached", whose letters are `tach`, not `attach`. It now matches planner, detach, attach, held, decisions and @chopin in any form, and a test shows it flags each banned term, "detached" and "reattach" among them, and none of the plain words. The docs and DESIGN.md say the same. Strings that name the Planner or Decisions and were not edited by this change (transcript lines, run cards, composer hints, busy labels, the setup popover Build plan opens) are listed in the pull request as untouched, and so is the known duplication of an offline notice on a parent and its open child. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 19ea4b06-26bd-4515-94f7-1201b0f8463c) Assistant-verification: bun test apps/web packages scripts e2e/focus-color.test.ts passed: 2639 pass, 3 skip, 0 fail, five more than before; bun test scripts passed again after the design-contract hash was renewed (137 pass, 1 skip) Assistant-verification: the new and rewritten cases were each seen red first: the button cases drew data-state="connected" for no machine and for every unhealthy state, the Build cases returned the Planner copy, and the old pattern let "detached" through; taking plannerGlyph's none back to undefined fails the button and audit plate cases, and swapping a Build refusal back to Planner words fails the Build case Assistant-verification: bun run types passed: every package and the e2e project type-check Assistant-verification: bun run fix then bun run ci passed: dprint, oxlint (71 warnings, 0 errors, as before), tokens, design contract (the reviewed hash of machine-button.tsx renewed, its one dynamic class unchanged), design record and Impeccable show no new findings Assistant-verification: bun run design:behavior passed: 22 keyboard, focus, reduced-motion, reflow and build-blocker checks at two widths Assistant-verification: E2E_SKIP_BUILD=1 bun run e2e planner-attachment planner-picker machine-choice machines connection implementation-launcher on a client built from this tree gave 93 passed and 10 failed, all 10 (8 in implementation-launcher, 2 in machines) with "either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured", this machine's global commit signing; the 93 include the new no-dot button test on desktop and phone Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e implementation-launcher machines passed: 32 passed, 0 failed, so the 10 are environmental Assistant-verification: screenshot review passed: audit-presence desktop and phone (only the healthy cell keeps the machine's green, and No machine connected shows the bare icon with no dot, its computed colours read back from the page), audit-composer-states and audit-build-blockers desktop, and the real-app planner-none-button desktop and phone, planner-starting and planner-failing-menu, were opened and checked against DESIGN.md Assistant-verification: not run: design:browser (Linux visual baselines are not regenerated), the full bun test, bun run test:postgres, the Docker image build, the whole e2e suite (six specs only), and GitHub's checks on a pushed head User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion Co-authored-by: Alex Lavaee <lavaman131@github.com> Assistant-duration: 20m 54s
…he teammate's strip fits On GitHub's runners the phone test for a machine that cannot start measured the teammate's composer strip while the Chat pane they had just tapped was still sliding in, its left edge at -229 px and then -67 px on the retries, so it failed on all three tries. The check now retries until the strip is inside the viewport, as Playwright's own assertions wait, and a strip that never fits still fails it. Assistant-model: Claude Opus 5.5 Assistant-workflow: inline Assistant-verification: GitHub e2e shard 2/12 failed: planner-picker.e2e.ts:644 at expectInsideViewport with the strip's left at -229 and -67 px on its retries Assistant-verification: E2E_SKIP_BUILD=1 bun run e2e planner-picker -g "keeps failing to start" --repeat-each 4 --workers 1 passed: 4 passed (parallel repeats collide on one Postgres database name, an environment limit) Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131
added a commit
that referenced
this pull request
Nov 6, 2026
… it holds the writer lease and has finished starting, and not ready from the moment a drain begins A proxy or platform that routes new connections had nothing to ask but `/api/session`, which an unauthenticated caller gets from any process that is listening, whether or not it holds the writer lease, has finished recovery, or is already shutting down. Two endpoints now answer one status word and nothing else, to a caller with no login and for GET and HEAD alike. `GET /api/health/live` is `200 live` while the process runs. `GET /api/health/ready` is `200 ready` only after the process holds the `chopin:writer` lease and its startup recovery (experiments, research, the job runner) has finished. It is `503 starting` before that and `503 draining` from the first statement of `drain()`, before the listener closes, and a drain that begins during startup keeps it from ever becoming ready. Neither answer is cached. The state is a small `Readiness` that reads the held lease through a closure, so a lost lease reads as not ready; its tests cover each transition. `startup.test.ts` now also asks a real process, started against PostgreSQL, for both endpoints. Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server", "What changes, by package", `apps/server` `main.ts` (add `GET /api/health/live` and `GET /api/health/ready`, both unauthenticated and returning only a status word), and "Security" ("Health endpoints are unauthenticated and reveal only a status word"); "Order and dependencies" puts the readiness endpoints first because they help even with today's stop-then-start deploys. Not part of this change: the design's other route-ready conditions (a database transaction completes, `apps/web/dist/index.html` exists), the warm-standby state in which `live` answers `standby`, and moving the image's `HEALTHCHECK` to `live`; the image health check still calls `/api/session`. docs/self-hosting.md describes the endpoints. One difference from the design, kept on purpose: readiness goes to `503 draining` from the first statement of `drain()`, as the task's brief asks (false from the moment a drain starts). The design says "Readiness is not turned off at SIGTERM" ("Hand-over sequence", step 3.1) and turns it off at its cut, with the process still serving, for a fixed unready window before the listener stops (step 3.5.1), because a proxy would otherwise hold every new connection through the wait for running turns. That sequence needs a second process, which this change does not add, and today's `drain()` has no such wait: it stops the listener right after turning readiness off, so there is no window in which the process answers while not ready. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0) Assistant-verification: bun test apps/server/src/http and apps/server/src/storage/postgres/startup.test.ts passed on this commit's own tree: 21 pass, 0 fail across 5 files, including the readiness and liveness cases and the live and ready answers of a real started process; bun run types and bun run ci passed on it Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet User-preference: Deploys must be strictly invisible to users Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131
added a commit
that referenced
this pull request
Nov 6, 2026
…eat that as a restart: reconnect after a jittered moment, say nothing for five seconds, send again a message the restart cut off, and read a proxy's 4xx as unreachable rather than as a refusal `server.stop(true)` closes every socket with 1001, "going away", so a restart looked to a tab like any other lost connection: the document said it was offline after 1.5 s, and any 4xx a proxy answered while no upstream existed (a 404 or a 403 with no Chopin behind it) was taken for a refusal and stopped the tab from retrying for good. Server: `OpenSockets` tracks every socket (browser, sidebar and agent link) and `stopForRestart()` stops the listener, closes each with 1012 and reason "server restarting", then drops what is left, in place of the bare `server.stop(true)` in `drain()`. A real Bun server shows clients 1001 without it and 1012 with it, under backpressure too. Admission's refusals of `/ws` and `/ws/sidebar` carry `x-chopin-refusal: 1` (`refused()`). The two existing 1012 closes in `main.ts` use the shared name, `SERVICE_RESTART`, defined with the header's name in `@chopin/protocol/restart`. Browser: `lossOf()` reads 1012 as a restart, 4404 as a deleted document and anything else as an ordinary loss; `refusalOf()` reads a probe's answer as a refusal only if it is a 4xx with the marker, or a 401, which still asks for a sign-in and keeps a rolled-back server's unmarked refusals from retrying forever. After a 1012 `Wire` reconnects after 0 to 1000 ms without first probing. For `RESTART_QUIET_MS` (5 s, defined with the other restart constants) after the loss, each failed attempt is followed by another 250 to 1000 ms later (`restartRetryDelay()`) and does not count towards the backoff, so a server that is back is found within about a second; after that the usual backoff goes on from where it was. `Wire.restarting` is true from the 1012 until the socket opens. The outbox, drafts and answers are untouched: they were never cleared by a loss. How this sits on the outage model of #792, which is kept as it is: `Wire` reports an outage through `onOutage`, the workspace's `isOffline` follows it, and its one offline notice (`useOfflineNotice`) is drawn from it after the usual 1.5 s grace, with the probe giving up after 4 s and the notice's plain copy. For a loss that is not a restart nothing changes: the outage is raised at once. For a restart, `Wire` raises no outage while `RESTART_QUIET_MS` lasts (`quietLeft()` in `wire-loss.ts` says how much is left), and the attempts that fail meanwhile are not counted towards the failures that decide when Retry is offered. So the offline notice, its polite word and its Retry stay away, and a restart that is over before then is never reported, not even as an outage that ended. If the server is still away when the period ends, the outage is raised then, from a timer if no attempt has failed by then, and from there it is #792's: the notice follows its ordinary grace, then Retry after the usual number of failed attempts, and "Reconnected" is said when the connection opens. Every other surface that speaks of a lost connection waits `RESTART_GRACE` (`connectionGrace()`), which is that quiet period and then the same ordinary grace, so they speak at the same moment as the notice does for any other loss: the editor's lock, the workspace's dimmed controls, the machine button (which goes with a lost connection, and is kept until then so that nothing comes or goes), a send held through a blip, and, for a turn in progress, the "Connection lost" mark in the transcript. Two things a restart used to say inside that silence, now kept quiet. A request still waiting for its answer when the 1012 arrives is rejected as `WireLost` with `restarting` set (a plain drop is not marked), and a Chat message in that position is held and sent again once the server is back instead of showing "Message not sent": its draft keeps its request id, and `replay` in `chat/service.ts` answers a request the server already holds with that message, so it arrives once whether or not the first attempt was read. A restart that outlasts `RESTART_GRACE` leaves the draft in the composer, and the document says it is offline. And a Planner turn in progress goes on showing as working through a restart that is being waited out in silence (`silentRestart()` and `progressOf()` in `transcript-state.ts`). That begins with a 1012, lasts through the moment after the connection opens until the transcript is current again, and ends once the page speaks of the connection; a loss that was not a restart marks the turn disconnected as soon as the connection that carried it is gone, as before (the smoke tests of that stand as they were). Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server": "What changes, by package", `apps/web` `wire.ts` (on a 1012 close, mark the loss as a restart; treat a probe answer as a refusal only if it carries the marker header, or is a 401); the drain's step "closes each socket and link with 1012" ("Hand-over sequence", step 3.5.5) and "Reference review" (send 1012 rather than rely on Bun's 1001); "Protocol" ("Close codes"); "Compatibility"; "Failure modes" ("Proxy answers 4xx in the gap"); and open question 6, which this answers for Bun 1.4.4. Three departures from that revision, each on purpose: 1. Jitter. The design says "Add no restart jitter: the first attempt keeps the existing ladder". The task's acceptance criteria require clients to reconnect quietly with jitter, so the first attempt waits 0 to 1000 ms (`RESTART_JITTER_MS`). That is a conflict between the design and the task contract, resolved in favour of the task contract. 2. Silence. The design keeps the existing 1.5 s silence for a restart ("What changes, by package", `packages/editor`: "no 'Updating…' label and no 5 s or 30 s timings are used, because no notice may show within the grace"). Here a restart-marked loss raises no outage for `RESTART_QUIET_MS`, 5 s, and the surfaces above then wait the ordinary 1.5 s on top of it. The design's 1.5 s assumes the Stage 1 hand-over, a second process already waiting to take over, which is out of scope. A restart that stops the old process before it starts the new one, as deployments do today, takes longer than 1.5 s, so 1.5 s would say the document is offline in the middle of a quick restart, which the task forbids. 3. Retries. After the first attempt the design goes on the existing ladder. Its waits (up to 3 s at its third step and 6 s at its fourth) found a server that was already back only after the 5 s, which a browser test showed as a notice, so for the first 5 s a failed attempt is followed by another about a second later. Left out of this change: the `session:restarting` frame, the `build` identifier and the agent link's `restart-notice` feature; the 1012 close of an agent link ends it exactly as the 1001 did. The current revision of the design drops the "Updating…" label and any longer offline threshold, so none was implemented. docs/self-hosting.md, docs/agent-connection.md and AGENTS.md say what the close and the header mean. The reviewed whole-file hash of plan-editor.tsx in the design-contract exceptions is renewed: its edit passes one more argument to a hook and leaves the `className` flow it records alone. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0) Assistant-verification: bun test of apps/web/src/wire.test.ts, wire-loss.test.ts, wire-quiet.test.ts, the chat directory, packages/editor/src/connection-notice.test.ts and apps/server/src/socket passed on this commit's own tree: 352 pass, 0 fail across 33 files, including the cases of wire-quiet.test.ts on fake sockets and time (a restart raises no outage within its 5 s however many attempts fail, says nothing at all when the server is back in time, raises the outage when the 5 s have passed whether or not an attempt has failed, does not count the attempts that failed inside it, and a loss that was not a restart is reported at once), the WireLost cases, and the cases of silentRestart() and progressOf() (a turn goes on showing as working while a restart is quiet, and is marked disconnected as soon as any other connection is gone); bun run types, bun run ci and bun run build passed on it (initial JavaScript 257,905 B raw / 81,523 B gzip, against limits of 258,000 and 82,328 B) Assistant-verification: mutation checks on this tree, each caught by the case for the code it breaks: quietLeft() returning none, so the outage is raised at once for a restart (6 cases failed), no timer for the end of the quiet period (3 failed), the attempts that failed in it counted towards Retry (1 failed), the restart grace the ordinary 1.5 s (1 failed), silentRestart() never silent (2 failed), quiet knocks counted towards the backoff (the backoff case failed), the `restarting` marker dropped from a rejected request (the WireLost case failed), and a 4xx taken as a refusal (2 cases failed) Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet User-preference: Deploys must be strictly invisible to users Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131
added a commit
that referenced
this pull request
Nov 6, 2026
…hunk that is gone only when it holds nothing the server has not taken After a deploy, a tab opened on the old build asks for chunks of that build. The server answered every missing path with `index.html` and status 200, so the browser got HTML for a module and failed with an error that said nothing about a new build, and nothing in the page handled Vite's `vite:preloadError`. Server: `serve()` answers 404 (plain text, `cache-control: no-store`) for any missing path under `/assets/`. Every other missing path is still the app, so a room URL survives a reload, and a file that exists is served as before, hashed ones immutable. The test that expected the app's HTML for a missing hashed asset now expects 404, and a new one covers hashed, unhashed, nested and directory paths. Browser: `watchPreloadErrors()` listens for `vite:preloadError` and reloads the page only when nothing is unsaved and the server answers with a page of another build than the one the tab runs: it asks for the current address and compares the entry script its HTML loads (`entryOf()`, a name that changes with any file of the build) with its own. A chunk also fails to load while the server is restarting, and a reload then lands on the browser's error page; and it fails for reasons that have nothing to do with a deploy (a request that is blocked or aborted, say), which the page's own recovery says what to do about, as it did, and a reload of the same build would not cure. So the failed search dialog, the failed Projects drawer and the failed chart keep their own errors and Reload buttons, and their browser tests are as they were. Work begun while the server was being asked stops the reload too. The event is never taken, so the failure goes on to be thrown to whatever was loading the chunk, as before, and a later failure reloads once the work is done. A reload that did not cure the failure is not repeated within ten seconds, remembered in `sessionStorage`; where storage refuses, no reload is made, so a build that is really broken cannot loop. What is unsaved is asked of `@chopin/protocol/pending-work`, where an owner is tracked weakly and says `unsaved` when read, or, for a component, through `usePendingWork()` while its text is held. Counted: the editor's outbox of unacknowledged updates (`PlanProvider`, the one `provider.ts` keeps until `plan:ack`), a Decision's draft edits and answer in flight (`QuestionnaireController`), a research brief's edits (`ResearchDraftController`), a Chat message not yet sent, a new comment's draft, a comment reply typed and not sent or sent and not yet answered (the words leave the field when sent and return if it is refused), a question typed into the document for research or an investigation and not started, or started and not yet placed (`ResearchDraftStore`), the "Your take" reply on a Decision's page, "What needs to change?" when returning a build to planning, the details of an exploration, an investigation's decision conclusion and rationale, an option or a criterion typed and not yet added, a document title being renamed, and a settled Decision's why being edited. Not counted, because nothing there is authored text that a reload would cost more than a moment: search and filter fields, the repository, URL, pairing, machine-name and command fields of the dialogs and the Machines page, the link and image fields of the editor's popovers, a tab's name (written into the document when the field is left, so the outbox holds it), and the excerpt correction form, which is prefilled from a Chat message and holds a choice from it. Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server": "What changes, by package", `apps/server/src/client.ts` ("Answer 404 for any missing `/assets/*` path instead of `index.html`") and `apps/web` `wire.ts` ("Add a `vite:preloadError` handler that reloads only when the editor outbox is empty and no composer draft is unsent. Otherwise it shows a quiet reload prompt"); "Approaches considered", the table under "Old tabs after a deploy" ("Answer 404 for a missing asset and reload on `vite:preloadError`", which may run only when nothing is pending); and "Failure modes" ("Old tab requests a chunk that is no longer kept"); "Order and dependencies" ships the asset 404 and the client fixes first. Two differences from the design's handler, both on purpose: it reloads on the event alone, and here the server must also answer with another build, because the event also fires while the server is away, which a browser test showed, and for failures that are not a deploy, which the browser tests of the page's own recoveries showed. The design's quiet reload prompt for the case where work is pending is not part of this change, and no text was added or changed for any of the work counted above. Not done here, and why: keeping the previous builds' hashed assets in the new image (the design's `PREVIOUS_IMAGE` build argument with `COPY --from`). The Dockerfile builds the client from source and copies only that build's `apps/web/dist`; nothing in this repository names a previous image or a registry (the CI container job builds `chopin:test` with `push: false` and the only release workflow publishes the CLI), and the design itself lists release identity and the registry under Stage 2 and as outside this repository (open question 12). Without the previous image the build has no assets to keep, so it is left for the work that gives a server release an identity. docs/self-hosting.md says what a tab does now and why the new image carries only its own files. The client build's initial JavaScript limit rises by 1 KB raw, to 259,000 B (`apps/web/bundle-budget.ts`): the build at the previous commit measured 257,905 B raw, 95 B under the limit, and this change's reload recovery and unsaved-work registry take it to 258,951 B raw / 81,980 B gzip, inside the unchanged gzip limit of 82,328 B. The reviewed whole-file hashes of comment-layer.tsx, document-rename.tsx and question-view.tsx in the design-contract exceptions are renewed: their edits add a hook call and an import and leave the style and class flows they record alone. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0) Assistant-verification: bun test of apps/server/src/client.test.ts, apps/web/src/preload-recovery.test.ts, packages/protocol, packages/editor, packages/question, apps/web/src/chat and apps/web/bundle-budget.test.ts passed on this commit's own tree: 1459 pass, 0 fail across 173 files, including entryOf() and isNewerBuild() and a failed chunk not reloading a page that holds a typed investigation question; bun run types and bun run ci passed on it Assistant-verification: mutation checks on this tree, each caught: every build counted as newer failed the two cases of isNewerBuild(), and `ResearchDraftStore` no longer tracked failed its research-draft case and the preload-recovery case Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet User-preference: Deploys must be strictly invisible to users Co-authored-by: Alex Lavaee <lavaman131@github.com>
lavaman131
added a commit
that referenced
this pull request
Nov 6, 2026
…s, and a chunk a deploy removed, in a real browser: nothing shown, no edit, draft or message lost or doubled, and a reload only when it is safe Nothing exercised a real restart with a tab left open: the reconnect tests close the browser's own socket, and the build restart test reloads the page afterwards. `e2e/restart.e2e.ts` starts a server of its own (port 8796, a database of its own in the cluster of `E2E_DATABASE_URL_3`, as `build-restart.e2e.ts` does), waits for it on `/api/health/ready`, and has eight cases. A restart under an open tab: a person types numbered words into a document without stopping while the server is sent SIGTERM, left away for a second, as a platform leaves a gap between the old container and the new one, and started again, with an unsent message in the composer. The old server must exit 0, and the restart must take longer than the 1.5 s a blip is waited out and less than the 4 s the page waits for a restart. The page itself samples, every 25 ms, whether it says anything about the connection: the document status, the composer's error line, the editor's lock, and the document's one offline notice with the word it says once it has gone, so no notice can come and go between two assertions. The browser must have been closed with 1012 and never 1001; nothing may have been said; the tab must not have reloaded, thrown or lost its place; the message must still be in the composer; and the stored document must hold every word exactly once and in order. A restart that outlasts its quiet period: the server is left away until the page speaks. Nothing is said for the first `RESTART_QUIET_MS` (the lock and the notice included), and then the document's one offline notice appears after its ordinary moment of grace, as for any other lost connection, and goes, with "Reconnected", and the editor is editable again when the server is back. A message sent as the server restarts, twice: the page's WebSocket goes through Playwright's `routeWebSocket`, and while the restart begins either the send is never read by the server, or the server takes it and the answer is lost with the connection. In both the composer says nothing, the same request id is sent again once the server is back, the draft goes when it is taken, and the message is in the room once, in the page and in what the server stored. A tab holding a reply or a question typed into the document is not reloaded for a chunk that is gone: with a comment reply typed and not sent, and then a research question typed into the document and not started, a `vite:preloadError` leaves the tab where it is; once the reply is sent and the question put away, the next one reloads it. An asset of a build that is gone: a missing file under `/assets/` answers 404 and not HTML from a real server, while the entry chunk is served as immutable. A `vite:preloadError` with the same build served does not reload the tab, whatever the reason for it. Once another build is served (`deployed()` makes the tab's request for its page name another entry script, and changes nothing else), a failure with an unsent message does not reload the tab, once the message is cleared it reloads the tab once, and one straight after does not reload it again. A chunk that fails while the server is away does not reload the tab into the outage: with another build to be served but the server stopped and nothing unsent, the event leaves the tab where it is, and the tab is quiet and editable again once the server is back. This is the case that found the reload would otherwise land on the browser's error page. A chunk a deploy removed: with a real lazy chunk answered 404, as a server answers for a file of the build before it, and another build served, the tab reloads once and then stops. The test found two things that were fixed in the commits before it, before the series was finished: after a restart the ladder's waits could find a server that was back only after the quiet 5 s, which showed a notice for a moment; and the reload for a failed chunk also ran while the server is away. One error is still allowed, and only that one. While no server answers, the sidebar's idle warm-up of its dialogs asks for the `dialog-prefetch` chunk and fails, which the console sees and nothing on the page shows; the restart cases accept exactly that error, for that chunk of this server, at most once, and only between the server being stopped and the new one answering. Any other page error, another chunk included, fails them. Design: zero-downtime upgrades (`docs/zero-downtime-upgrades-design.md` at revision 29ca4ca of the design branch), "Stage 1: invisible restarts on one server", "Tests", "New end-to-end test (does not exist today)": a real overlapping restart under an open tab, typing continuously while a warm server takes over from the old one, asserting no reload, no notice of any kind, no `replaced` reset, that every character appears once, and that an old chunk URL gets 404; it also measures the notice-free gap and fails above 1.5 s. This test differs on purpose. Chopin has no second process to overlap with, so it restarts stop-then-start, with a gap of a second between the two servers, and asserts no notice through an outage of 1.5 to 4 s, which the 5 s quiet period (`RESTART_QUIET_MS`, a departure from the design's 1.5 s described in the commit that adds it) allows and a 1.5 s silence would not. Left out, because they need the persisted state or the overlap of later work and not this stage: the design's remote agent client holding an open question, the MCP session ID, a message queued behind a running turn, and a second server that waits for the first. Assistant-model: Claude Sonnet 5.5 Assistant-workflow: goal (run 506e8b83-3d0b-4f0f-91bd-b33d78470de0) Assistant-verification: bun test apps/web/src/wire.test.ts and apps/web/src/chat passed on this commit's own tree: 292 pass, 0 fail across 24 files (the commit changes only a browser test); bun run types and bun run ci passed on it Assistant-verification: mutation check in the browser, on a rebuilt client: with the outage raised at once for a restart, so that the offline notice ignored the quiet period, both the restart case (the notice at about 3.4 s of the sampler's timeline) and the case of a restart that outlasts its quiet period failed Assistant-verification: bun test (the whole repository) passed on the final tree of the series: 7396 pass, 17 skip, 0 fail across 886 files, with a disposable PostgreSQL 17 at TEST_DATABASE_URL on port 5440 (the PostgreSQL contract and startup tests run inside it), run with GIT_CONFIG_GLOBAL=/dev/null because the global commit signing breaks one test's scratch repository Assistant-verification: bun run fix changed nothing on the final tree, and bun run types and bun run ci passed on it and, after the rebase onto main at 86c5b69, on each of the four commits' own trees, checked out one at a time: 72 lint warnings on the final tree (71 on the first three commits), tokens, design contract (1116 sources, 330 reviewed exceptions on the final tree), design record and design check Assistant-verification: bun run e2e with a freshly built client on the final tree ran the whole browser suite, which includes restart, connection, chat-composer, editing, collab, hosted, build-restart, experiments and the specs #792 added or changed (connection, editing, planner-attachment, planner-picker): 908 passed, 6 skipped, 0 failed, none retried, against four fresh PostgreSQL 17 databases of one server at port 5440 (ports 8788 to 8797 checked free first); e2e/restart.e2e.ts alone with --repeat-each=2 --workers=1 passed 16 of 16 (its cases each start a server on a fixed port, so the repeats cannot run side by side), on the tree before the last revision of e2e/experiments.e2e.ts to main's, which restart does not touch Assistant-verification: e2e/experiments.e2e.ts, as main has it, failed once on the final tree, in its first run after the client was rebuilt (the chart's own error was not shown within 10 s; not reproduced, not explained), and passed in the whole-suite run above and in 6 other runs, alone and together with restart Assistant-verification: bun run build, inside that e2e run, gave initial JavaScript of 258,951 B raw / 81,980 B gzip on the final tree, against limits of 259,000 and 82,328 B Assistant-verification: apps/web/src/wire.test.ts (33 tests) passed 30 of 30 runs in isolation, one process at a time, and wire-quiet, wire-loss, preload-recovery, transcript-state and connection-notice (55 tests) passed 30 of 30, on the tree before the last revision of e2e/experiments.e2e.ts, whose web code and tests are those of the final tree Assistant-verification: not run: a Docker image build (no Dockerfile, dependency or workspace change), the design and diagram suites, the Windows checks, the browser-level mutation checks of earlier rounds (the Chat hold of a restart-cut send, the comment composer and the research store in the reload guard, the allowed idle-prefetch chunk), and GitHub's checks (no branch was pushed and no PR was opened); no test covers the machine button being kept while a restart is quiet User-preference: Deploys must be strictly invisible to users Co-authored-by: Alex Lavaee <lavaman131@github.com> Assistant-duration: 62m in the first round and 106m in the second (04:12 PM to 5:58 PM PST on 2026-11-05, by `date`) and 146m in this one, the rebase onto main (06:14 PM to 08:40 PM PST on 2026-11-05, by `date`), about 314m of work not counting the time between the rounds
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A healthy Chopin says nothing. How the machine behind a document is doing now lives on the document header's existing machine button, in plain words that name no Chopin concept; a failure someone can act on also warns on the composer's top edge; and a lost connection is one calm notice at the top of the page. Nothing sits under the composer and nothing about the connection sits in it, so the composer is the same size in every state.
The words are the user's, and plain: no "Planner", "attach", "detach", "held", "Decisions" or "@chopin" in any status, button name, strip, notice, Build tooltip or refusal, or screen reader announcement. A unit test walks every state, glyph and place that says them, Build's reasons and refusals included, and fails on any of those words; its pattern is itself tested against each banned term, "detached" among them.
State by state, where it is told, and in what words:
A lost connection is the page, not the machine: a warning InlineNotice at the top of the document's content, after a 1.5 s grace period, You're offline. Your changes are kept and will sync when you reconnect. Retry only after 4 failed reconnect attempts. On compact windows and phones it is one element above Chat and the document.
Where a state is told: one decision
When the person has the machine button, the button and its menu carry every state, and the composer's top edge is used only for a machine that cannot be connected to, the one state a person can act on. When the person does not have the button (no machine paired or shared, a title bar that does not show one, a phone whose top bar an open child has taken), the composer's top edge is the fallback carrier for every state, always and not only while a message is addressed to Chopin, and for a machine that cannot be connected to it also draws the reason inline. One pure function,
plannerPlacement(status, hasButton), makes that decision for the button, its menu and the strip, so they cannot disagree;room-workspace.tsxcomputeshasButtonfrom the same conditions that draw the button. A lost connection hides the button without moving where the state is told; the offline notice speaks for the connection meanwhile.The machine button with no machine connected: no dot
When a document's machine says No machine is connected. the machine button now draws no status dot: the bare icon in tertiary ink, exactly as the button of a person who has no machine at all. It used to keep the machine's own dot, so the connected green sat beside those words. I chose no dot over the muted dot from the status-graphics rules in
apps/web/DESIGN.md: success is reserved for a connected machine, and the muted tertiary dot already means a machine that is there but unreachable (offline, stepped away), which is not true of a document with nothing connected to it. Absence is drawn as absence, the existing treatment for a button with no machine, and it is the quieter of the two. Every state that is not healthy now draws the Planner's own dot or none, and that dot carries nodata-stateat all, so the machine's green cannot come back through the cascade.plannerGlyphmapsnoneto"none"andMachineGlyphdraws nothing for it; the design audit plate takes its dots from the same function.Build says it in machine words too
The Build plan tooltip and the refusal after pressing it used to say Reconnect the Planner to build and Connect a Planner to this document to build. They now say Reconnect the machine to build and Connect a machine to this document to build (with a stop when it is the refusal shown after a press), the same machine-first words as Connect a machine to get a reply. I wrote "the machine", not "your machine", because the document can be running on a machine a teammate shares, which the person pressing Build cannot reconnect. The connect copy is one constant,
CONNECT_MACHINE_TO_BUILD, shared by the tooltip, the refusal and the key Build plan uses to offer how to connect a machine, so copy and key cannot drift apart.status-copy.test.tsxwalks every reason Build plan can be inert while drafting and every refusaldraftRefusalCopycan give.What changed
apps/web/src/wire.ts): reports an outage (onOutage) when a connection is lost, each failed attempt in it, and when it opens again; automatic and manual attempts both count and only an open socket resets the count. New in this round: the status becomesreconnectingthe moment the socket closes, not after the HTTP probe that asks why; the probe has a 4 s timeout so a probe that never answers (a network that drops packets) cannot stall retries; theonlineevent wakes a wire that is waiting on the probe as well as on the backoff timer. The jittered exponential backoff is unchanged.isOffline(packages/editor/src/connection-notice.ts): follows the outage the connection reports, not only its status, so a hung probe still shows the notice after the grace period. A slow first connection, a refusal and a deleted document are still not an outage.apps/web/src/offline-notice.tsx): the decision (grace, Retry, the one polite word) is held inuseOfflineNoticeinRoomWorkspace, above wherever the notice is drawn;OfflineNoticeonly eases open and shut from what it is told, so a notice mounted again somewhere else is simply there.Workspacedraws it once above Chat and the document where they take turns (pageNoticePlace), so switching tabs during an outage neither restarts the grace period, nor remounts the alert, nor announces it again.--phone-notice-heightand added to every pane's top inset (--phone-pane-top), so the first of the Document's or Chat's content starts below it instead of under it. It sits above the panes (z-index 20) so Retry can be pressed.apps/web/src/chat/planner-status.ts, pure): one model for the button's name and tooltip, its menu, the composer's strip and the live region, with the plain words above. The failure reason is kept exactly as the machine reported it: not trimmed and no stop added or removed (a path that ends in a slash stays a path); layout and separate lines carry the separation instead.plannerPlacementis the placement decision;plannerAnnouncementis the once-per-entered-state rule.chat/planner-announcement.tsx): one visually hidden politerole="status"inRoomWorkspace, independent of the button or the strip, that says each problem state's sentence once as it is entered, nothing while healthy, nothing as a state clears, and nothing as the attempt count rises. The strips carry no live role of their own (silent), so nothing is announced twice.composer.tsx): the deadattachmentslot under the composer is gone. A strip may show its detail lines (the reason, then the tries) or hand them to a screen reader only.apps/server/src/harness/remote/attachments.ts,docs/agent-connection.md): the@chopinand comment refusals are machine-first and plain: Connect a machine to get a reply. Choose one with the machine button, or runchopin connecton a machine that can reply., @mona's machine is offline, so it can't reply now. Your work is kept and picks up when it's back., Still connecting to @mona's machine. Try again in a moment., Can't connect to @mona's machine: (3 attempts in a row). It keeps trying; try again once that is fixed. The error codes (planner-not-attached) are unchanged, and Build's matching of these messages (draftRefusalCopy) is unchanged; only the copy it returns is plain now (below).Motion
The offline notice and the composer's strip are the only things that move: the grid track eases from 0 to its height in 250 ms and back in 200 ms while the content fades and settles, so what is below is pushed a frame at a time. The e2e traces the document's and the composer's top edge on every animation frame and asserts they pass through intermediate values; with
prefers-reduced-motionnothing is passed through. Clearing is quiet: no check mark, no "Back online", no toast.Accessibility
Research, and what was copied
Placement, scope and copy follow Primer's patterns; colour and components are Chopin's own (
apps/web/DESIGN.md).Primer
role="status"region always rendered.Apple Human Interface Guidelines, Progress indicators, https://developer.apple.com/design/human-interface-guidelines/progress-indicators: indicators are transient and specific, in a consistent place (the machine button).
Notion, Linear and Figma (research, 2026-11-05; the vendors document very little of this): changes are kept locally and sync on their own when the connection returns (Linear docs, https://linear.app/docs/get-the-app; Notion help, https://www.notion.com/help/guides/working-offline-in-notion-everything-you-need-to-know; Figma, https://help.figma.com/hc/en-us/articles/360040328553-What-can-I-do-offline-in-Figma). We kept Chopin's existing 1.5 s grace period rather than copy any vendor's number, and Retry, which appears only after repeated failures, is ours.
Honest residuals and decisions worth a second look
forbiddenMessage, ... its Planner was released, A document has one Planner at a time); the run cards' notes and Pause labels (It carries on when the Planner reconnects, Pause when the Planner reconnects); the composer's picker hints (Sends to the Planner, which will reply, Room only. Add @chopin to ask the Planner) and the@chopinmention itself; the busy labels Planner is working (document status), Planner working (Chat tab) and Document, Planner writing; the Decisions tab and Showing Decisions: N to answer; The Planner job could not be retried; the Machines page's Planner and runs Planners labels; and the setup popover that Build plan opens when no machine is connected (Connect Chopin, Planner command, Open your Planner and attach this document), a terminal setup guide for a custom backend that this change does not touch. Two design audit captions (the Conversation plate's description and the Workflow runs plate's) still say Planner too; the audit labels this change wrote do not. Whether any of these should follow is a decision for a deliberate change, and several are visible in the same Chat as the new strip.Verification
Run on the final working tree, with the client built from it. This round's checks first, then what the earlier commits ran that was not repeated.
This round:
bun test apps/web packages scripts e2e/focus-color.test.ts: 2639 passed, 3 skipped, 0 failed (five more than before: the rewritten no-machine test, the iterate-every-unhealthy-state test, the leak-check self-test, Build's reasons and refusals, and Build plan's tooltip with its connect key).bun test scriptsagain after renewing the design-contract hash: 137 passed, 1 skipped, 0 failed. The new tests were each seen red first: the button tests drewdata-state="connected"beside No machine is connected., the Build tests still returned the Planner copy, and the leak check was shown to miss "detached" with the old pattern; the audit plate test and the leak check also fail when the fix is taken back out.bun run types: passed (14 packages and the e2e project).bun run fixthenbun run ci: passed. dprint, oxlint (71 warnings and 0 errors, as before), tokens, design contract, design record and Impeccable (no new findings). The contract asked for the reviewed hash ofmachine-button.tsxto be renewed; its one dynamic class (presence.className, finite state classes from the shared presence hook) is untouched, so only the hash changed.bun run design:behavior: 22 passed, including the build blockers plate whose Connect Chopin trigger is still found by the new copy.E2E_SKIP_BUILD=1 bun run e2e planner-attachment planner-picker machine-choice machines connection implementation-launcher): 93 passed and 10 failed. All 10 failures (8 inimplementation-launcher, 2 inmachines) end infatal: either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured, this machine's global commit-signing config.GIT_CONFIG_GLOBAL=/dev/nullwithimplementation-launcherandmachinesonly: 32 passed, 0 failed, so they are environmental. The 93 include the new real-app test: with a machine that cannot reply, the button is named Runs on desk. No machine is connected., has no.machine-glyph-dotand no[data-state]on desktop and on a phone, its menu still says Connected for the machine's own row, and the page's live region says it once.design:browser(Linux visual baselines are not regenerated), the fullbun test(carried below from the earlier commits),test:postgres, the Docker image build, and the whole e2e suite (six specs here); GitHub's checks run on this pull request.Carried from the earlier commits and not repeated: the full
bun testgave 7092 passed, 22 skipped, 101 failed, 1 error, every one inapps/connectororapps/server/src/experiments/routes.test.tswith the same signing error, and 332 passed withGIT_CONFIG_GLOBAL=/dev/null; the whole 15-spec browser run gave 184 passed before this round.Screenshots
The full set was captured with
CHOPIN_SHOTS=/tmp/chopin-shots-quiet-offline(and a script against the design audit) on desktop and on a 390 px touch phone, opened and reviewed. Not committed. The design audit plates were regenerated this round, and the real-app shots were retaken by the browser run above.Offline:
offline-desktop.png,offline-retry-desktop.png,offline-phone.png,offline-retry-phone.png,offline-phone-document.png(real Document content below the notice),offline-phone-chat.png(real Chat content below the notice),lost-edits-after-offline-desktop.png.With the machine button:
planner-starting-desktop.png,planner-starting-phone.png,planner-failing-desktop.png,planner-failing-menu-desktop.png,planner-failing-phone.png,planner-failing-sheet-phone.png,planner-attached-desktop.png,planner-attached-phone.png, and, new,planner-none-button-desktop.pngandplanner-none-button-phone.png(a machine that cannot reply: the button is the bare icon with no dot).With no machine button, the composer's top edge carrying the state:
planner-starting-teammate-desktop.png,planner-starting-teammate-phone.png,planner-failing-teammate-desktop.png,planner-failing-teammate-phone.png,link-drop-disconnected-desktop.png,link-drop-disconnected-phone.png,planner-held-desktop.png,planner-held-phone.png,planner-none-desktop.png,planner-none-phone.png.Design audit:
audit-presence-desktop.pngandaudit-presence-phone.png(only the healthy cell has the machine's green; No machine connected: no dot shows the bare icon beside No machine is connected.),audit-composer-states-desktop.png,audit-composer-states-phone.png,audit-build-blockers-desktop.png,audit-build-blockers-phone.png(the Connect Chopin trigger),audit-loading-status-desktop.png,audit-loading-status-phone.png.Deferred
design:browserbaselines for the Conversation and Loading plates.Assistant-model: Claude Sonnet 5.5
Assistant-workflow: goal (run 19ea4b06-26bd-4515-94f7-1201b0f8463c)
Assistant-verification: bun test apps/web packages scripts e2e/focus-color.test.ts passed: 2639 pass, 3 skip, 0 fail, five more than before; bun test scripts passed again after the design-contract hash was renewed (137 pass, 1 skip)
Assistant-verification: the new and rewritten cases were each seen red first: the button cases drew data-state="connected" for no machine and for every unhealthy state, the Build cases returned the Planner copy, and the old pattern let "detached" through; taking plannerGlyph's none back to undefined fails the button and audit plate cases, and swapping a Build refusal back to Planner words fails the Build case
Assistant-verification: bun run types passed: every package and the e2e project type-check
Assistant-verification: bun run fix then bun run ci passed: dprint, oxlint (71 warnings, 0 errors, as before), tokens, design contract (the reviewed hash of machine-button.tsx renewed, its one dynamic class unchanged), design record and Impeccable show no new findings
Assistant-verification: bun run design:behavior passed: 22 keyboard, focus, reduced-motion, reflow and build-blocker checks at two widths
Assistant-verification: E2E_SKIP_BUILD=1 bun run e2e planner-attachment planner-picker machine-choice machines connection implementation-launcher on a client built from this tree gave 93 passed and 10 failed, all 10 (8 in implementation-launcher, 2 in machines) with "either user.signingkey or gpg.ssh.defaultKeyCommand needs to be configured", this machine's global commit signing; the 93 include the new no-dot button test on desktop and phone
Assistant-verification: GIT_CONFIG_GLOBAL=/dev/null bun run e2e implementation-launcher machines passed: 32 passed, 0 failed, so the 10 are environmental
Assistant-verification: screenshot review passed: audit-presence desktop and phone (only the healthy cell keeps the machine's green, and No machine connected shows the bare icon with no dot, its computed colours read back from the page), audit-composer-states and audit-build-blockers desktop, and the real-app planner-none-button desktop and phone, planner-starting and planner-failing-menu, were opened and checked against DESIGN.md
Assistant-verification: not run: design:browser (Linux visual baselines are not regenerated), the full bun test, bun run test:postgres, the Docker image build, the whole e2e suite (six specs only), and GitHub's checks on a pushed head
User-preference: Status UI stays quiet when things are healthy and calm when they aren't, like Notion
Co-authored-by: Alex Lavaee lavaman131@github.com
Assistant-duration: 20m 54s